device/telemetry: keep probing when the epoch fetch fails - #4143
device/telemetry: keep probing when the epoch fetch fails#4143elitegreg wants to merge 6 commits into
Conversation
The pinger fetched the current epoch before every tick and returned early on failure, so one unreachable ledger RPC endpoint stopped all TWAMP probing. Probing is pure UDP; the epoch only builds the sample buffer's partition key. During the 2026-07-29 outage this cost up to 19 hours of latency samples per device across 23 mainnet-beta devices. Cache the last known epoch and refresh it on its own loop, so the probe path never blocks on RPC. A failing fetch burns ~130s across its retries and the probe ticker only buffers one tick, so an inline fetch also swallowed roughly a dozen probe opportunities per failure. Probing is refused only when no epoch has ever been fetched, or when the cached one is older than -max-epoch-staleness (default 24h), past which a rollover is likely enough that samples would be misattributed to the previous epoch's account. Both cases log once rather than per tick, and repeated fetch failures collapse into fresh/stale transitions. Fixes #4125
There was a problem hiding this comment.
Pull request overview
Improves resilience of the device telemetry agent’s TWAMP probe loop by decoupling epoch fetching from the probe tick path and falling back to a cached epoch during ledger RPC outages, preventing probe gaps like the 2026-07-29 incident.
Changes:
- Cache the last known epoch in
Pingerand keep probing during epoch fetch failures, with a configurable staleness bound and log-once transitions. - Add a background epoch refresh loop plus new metrics/error typing to make epoch-cache health observable.
- Add targeted subtests covering fallback, recovery, refusal cases, staleness cutoff, and non-blocking behavior under a hung epoch fetch.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| controlplane/telemetry/internal/telemetry/pinger.go | Adds epoch cache + refresh loop; probes use cached epoch and refuse only when unavailable/too stale. |
| controlplane/telemetry/internal/telemetry/pinger_test.go | Adds subtests validating fallback, recovery, staleness refusal, log coalescing, and non-blocking probe cadence. |
| controlplane/telemetry/internal/telemetry/config.go | Introduces MaxEpochStaleness config with defaulting in validation. |
| controlplane/telemetry/internal/telemetry/collector.go | Plumbs MaxEpochStaleness and NowFunc into PingerConfig. |
| controlplane/telemetry/internal/metrics/metrics.go | Adds epoch-cache staleness gauge and pinger_epoch_unavailable error type. |
| controlplane/telemetry/cmd/telemetry/main.go | Adds -max-epoch-staleness flag wired into telemetry config. |
| CHANGELOG.md | Documents the behavioral change and new flag in Unreleased notes. |
Resolves: #4128 Independent of #4144 and #4145 (different file), so this one branches from `main`. ## Summary of Changes - `ledgerPeerDiscovery.refresh` no longer empties the peer cache before doing work that can fail. It cleared `p.peers` under the lock and then called `LocalNet.Interfaces()`, so a transient failure there returned with zero peers and `Pinger.Tick` iterated an empty slice, probing nothing until a later refresh succeeded. - The cache is now replaced only once the new list is built, and the lock covers just that assignment rather than the whole build. The clear was redundant with the existing assignment at the end of the happy path. - Success path is unchanged. ## Diff Breakdown | Category | Files | Lines (+/-) | Net | |------------|-------|-------------|------| | Tests | 1 | +79 / -0 | +79 | | Core logic | 1 | +6 / -5 | +1 | | Docs | 1 | +3 / -0 | +3 | | **Total** | 3 | +88 / -5 | +83 | A one-line behavioral fix plus the regression test that pins it. <details> <summary>Key files (click to expand)</summary> - [`controlplane/telemetry/internal/telemetry/peers.go`](https://github.com/malbeclabs/doublezero/pull/4146/files#diff-9c369dff3cb79259b8bc34d8d952b923103baeef1515402614c23a997a06c286) — drops the `p.peers = make(...)` clear, moves the mutex to wrap only `p.peers = peers`, and leaves a comment that nothing in the build may clear the cache </details> ## Testing Verification - New test lets the first refresh discover a peer, then fails every subsequent `LocalNet.Interfaces()` call, and asserts `GetPeers()` still returns the fully populated peer (link, device, tunnel, TWAMP port) after at least three failed refreshes. It fails against the pre-fix code, which returns an empty list. - Existing peer discovery tests pass unchanged, covering the success path and the skip cases. - Package passes under `-race`, since the change moves what the mutex covers.
ben-dz
left a comment
There was a problem hiding this comment.
The core move is right and the central claim holds — write_device_latency_samples.rs never reads Clock and takes no epoch argument, and initialize_device_latency_samples.rs:193 uses args.epoch only as PDA seed material, so past-epoch writes are accepted.
Two major things to fix:
-
The 12h default sits past the buffer underneath it.
partitionBufferCapacity = 4096at the default-probe-interval=10sis 11.4h of samples for one link, inside the 12h window. Oncesubmitter.go:248's over-capacity check trips, the entire accumulated backlog is discarded in one tick rather than the oldest slice, so a 12h outage yields roughly 36 minutes of samples on recovery. One-line fix: set the default below the buffer's real retention (say, 11h) -
Use the 11h as a ceiling for longest it will store, but also store how long the Epoch actually lasts and don't store samples past it. The data is already on the wire and thrown away — main.go:339-345 keeps only epochInfo.Epoch and discards SlotIndex/SlotsInEpoch, so no extra RPC. "Round down a little" needs to be ~15%, not minutes: the 400ms constant overshoots by 9%, i.e. up to ~4h right after a boundary — or derive the slot rate from consecutive absoluteSlot deltas, free at the 10s refresh cadence. CalculateEpochTimes already exists at epoch.go:14 but sits under monitor/internal/, so telemetry can't import it.
-
Stale-epoch samples are unreachable through the time-range read path —
controlplane/telemetry/internal/data/device/latencies.go:156-193.getCircuitLatenciesForTimeRangeresolvesfrom/toto epoch numbers viaEpochFinder.ApproximateAtTimeand iteratesstartEpoch..endEpochonly (:168), then filters by synthesized timestamp (:193). When an outage crosses an epoch boundary, post-boundary samples land in epoch N's account with timestamps inside N+1's window; any query scoped entirely to N+1 — a "last 6h" dashboard — resolvesstartEpoch == endEpoch == N+1, never opens N's account, and returns a silent gap. Epoch-scoped queries do return them.
This narrows the tradeoff the description states: "epoch precision, not measurements" holds for the raw account and for epoch-scoped reads, but for the consumer path dashboards actually use, cross-boundary samples are missing rather than mislabeled. Worth stating plainly in the changelog even if the behavior stands. Fix: widen the reader's epoch scan by one on each side, or bound staleness by the time remaining in the last known epoch rather than a flat duration.
- Submitter retries restart at batch 0, duplicating committed samples once a backlog exists —
controlplane/telemetry/internal/telemetry/submitter.go:95-170.SubmitSamplesbatches withfor i := 0; i < len(samples); i += MaxDeviceLatencySamplesPerBatchand returns on the first failing batch;Tickthen re-invokes it from scratch, so batches already committed are written again. Pre-existing code, but this PR makes it reachable at scale:tmpis normally 6 samples (one batch, no partial-failure window), whereas an hours-long backlog is ~17 sequential transactions per attempt, retried up to 5 times, precisely while the RPC is still flaky. Duplicates inflatenext_sample_index, which shifts every synthesized timestamp for that account — the same corruption mechanism as the staleness-bound finding — and can push the account toSamplesAccountFull, where the submitter drops the whole partition.
Fix: track the batch index across attempts, or retry per batch rather than per call. Reasonable as a follow-up PR given it is pre-existing, but it should not stay unowned once this change makes multi-batch flushes routine.
| // the ledger RPC stops answering. Samples recorded with a stale epoch are written to that | ||
| // epoch's account, so once we are far enough behind that a rollover is likely they would be | ||
| // misattributed to the previous epoch. | ||
| DefaultMaxEpochStaleness = 12 * time.Hour |
There was a problem hiding this comment.
The 12h bound outlives the buffer, and the overflow silently backdates every later sample in the account.
partitionBufferCapacity = 4096 per partition (collector.go:17); at the default -probe-interval=10s that is 11.4h of samples for one link — inside this 12h window. During a ledger outage the submitter cannot drain either, so each 60s tick does CopyAndReset, fails all 5 attempts, and prepends the batch back only while Len + len(tmp) < Capacity. The first time that check trips at ~11.4h (submitter.go:248), the entire accumulated backlog is discarded in one tick rather than the oldest slice, and accumulation restarts from zero. A 12h outage therefore yields roughly 36 minutes of samples on recovery, not 12h.
What makes this High rather than a tuning nit is what the discard does to the data that does survive. The onchain account stores one start_timestamp_microseconds, one sampling_interval_microseconds, and a flat samples array with no per-sample timestamps (smartcontract/programs/doublezero-telemetry/src/state/device_latency_samples.rs:117-123), and the reader synthesizes each timestamp as start + i*interval (controlplane/telemetry/internal/data/device/latencies.go:205). A dropped run leaves no marker, so every sample appended after the discard is backdated by the full 11.4h gap. Wrong data presented as good data is worse than the probe gap this PR removes.
This state was not reachable before the change: the pre-fix pinger stopped adding during an outage, so the partition never grew and the over-capacity path never tripped.
Fix (one line for the immediate hazard): set DefaultMaxEpochStaleness below the buffer's real retention — derive it as partitionBufferCapacity × ProbeInterval, or hardcode a value with margin (8h) rather than one past the cliff. The comment above should also name the real binding constraint; rollover likelihood is not it, given a ~44h ledger epoch and no onchain epoch check. Worth a follow-up: have the over-capacity path keep the newest Capacity - Len samples instead of dropping all of tmp.
Test: a submitter-level test with a small partitionBufferCapacity and a failing WriteDeviceLatencySamples, asserting the observed sample rate stays within tolerance of ProbeInterval across the whole allowed staleness window.
There was a problem hiding this comment.
Fixed, in both halves.
The default is now 10h rather than 11h, and the collector clamps whatever is configured to partitionBufferCapacity × ProbeInterval × 0.95 (collector.go, maxEpochStaleness). 10h so the default does not itself trip the clamp warning at the default probe interval — 11h is inside the 11.4h cliff but past the 10.8h the headroom leaves, and a warning on every healthy startup is noise. The clamp is what keeps a non-default -probe-interval safe: at 5s the buffer only holds 5.7h, and a flat 10h would be past the cliff again.
The comment above the constant now names the buffer as the binding constraint, and says what the overflow does to the data rather than talking about rollover likelihood — the rollover is bounded separately now, see below.
Tests: TestMaxEpochStaleness covers the clamp arithmetic and asserts the default does not need clamping. TestSubmitter_RetainsEverySampleAcrossTheStalenessBound is the submitter-level one you asked for — small capacity, WriteDeviceLatencySamples refusing every write, probing for the full window the bound allows, then asserting every sample survives the flush once writes recover. It also require.Less(probes, capacity) up front, so if the bound ever drifts back past the buffer the test fails on the premise rather than the assertion. Verified both fail with the clamp no-oped and the default back at 12h.
The over-capacity path keeping the newest Capacity - Len instead of dropping all of tmp is filed as #4149.
| if !have { | ||
| // Nothing cached: either the agent just started and the refresh loop has not produced a | ||
| // value yet, or Tick is being driven directly. Fetch inline so the tick is not wasted. | ||
| epoch, err := p.getCurrentEpoch(ctx) |
There was a problem hiding this comment.
The inline fetch makes epochForTick a second concurrent writer to the epoch cache, and each act-on-stale-read is observable.
haveEpoch is read under the lock at lines 205-207, then this fetch runs for tens of seconds when failing. Three consequences follow from acting on that stale read, and from emitting side effects outside the critical section that flipped the corresponding flag:
- If the concurrent
refreshEpochLooplands an epoch during the retry window, the failure branch below still fires — it incrementserrors_total{pinger_epoch_unavailable}and logsError("No epoch available and none cached, skipping probes until the ledger answers")while a fresh epoch sits in the cache. An operator-facing ERROR and an error-counter increment on a healthy agent is a false page source, in exactly the flapping/partially-degraded-RPC scenario this PR targets. markEpochStalesetsservingStaleunder the lock at 316-317 but logs at 330. AstoreEpochfrom this inline path can run in the gap and log"Epoch fetch recovered"(302) before the failure it recovered from, withstaleFor ≈ 0.markEpochStalereadsatunder the lock at 315 but callsEpochCacheStaleAge.Set(age)outside it at 327, racingstoreEpoch'sSet(0)at 298. The gauge can be left large right after a successful fetch. It self-heals in one refresh interval, but this gauge is the operator's only staleness signal.
Fix: make refreshEpochLoop the sole writer and have epochForTick read the cache only. The cost is one skipped tick at startup, and Tick-without-Run no longer self-seeds, which affects the subtest at pinger_test.go:440. If the inline fetch stays, re-read the cache under mu after it fails and fall through to the staleness check at line 232 when an epoch landed meanwhile, and move each log and Set inside the critical section that flips its flag.
Test: a -race test driving Tick concurrently with Run while the fetch flaps, asserting no ERROR is emitted while an epoch is cached, and that the gauge is 0 whenever the last fetch succeeded.
There was a problem hiding this comment.
Fixed the way you suggested: RefreshEpoch is the sole writer and epochForTick only reads the cache and decides. The inline fetch is gone, so all three consequences go with it — no ERROR or error-counter increment while a fresh epoch is cached, no recovery logged before the failure it recovered from, no gauge left large after a success. Every log line and every Set now happens inside the critical section that flips its flag.
On the cost you flagged: refreshEpochLoop refreshes immediately on start and the first probe tick is Interval later, so a healthy agent skips nothing. Tick-without-Run no longer self-seeds, so the three affected subtests now call RefreshEpoch explicitly — which reads better anyway, since the seeding is now visible instead of a side effect of the first tick. refreshEpoch is exported as RefreshEpoch for that, mirroring how Tick exposes one step of the probe loop.
Test: TestAgentTelemetry_PingerEpochMetrics/a_flapping_fetch_never_reports_an_error_while_an_epoch_is_cached drives Tick in a loop against a running Run with the fetch flapping, under -race, and asserts no ERROR is emitted and pinger_epoch_never_fetched does not move. It then settles the flapping on a success and asserts the gauge is 0.
|
|
||
| if !have { | ||
| // epochForTick reports this case; there is no cached epoch to fall back to. | ||
| p.log.Debug("Failed to get current epoch, none cached", "error", err) |
There was a problem hiding this comment.
markEpochStale returns here before setting EpochCacheStaleAge, so the gauge reads 0 in the worst state — restarted mid-outage, no epoch ever fetched, zero probing — exactly as it does when healthy. Emit a sentinel on this branch, and add the one subtest that would have caught it (testutil.ToFloat64 asserting the gauge tracks age while stale and returns to 0 on recovery). Neither new metric has any coverage today.
There was a problem hiding this comment.
Fixed. That branch now sets the gauge to +Inf, so an alert on stale_age > threshold fires in the state where nothing is being probed at all, rather than reading identically to healthy. +Inf rather than -1 for exactly that reason — a negative sentinel would pass the same alert silently. Noted in the metric's Help string.
Both new metrics now have coverage: TestAgentTelemetry_PingerEpochMetrics has one subtest walking the gauge through fresh → stale → staler → recovered with testutil.ToFloat64, one asserting the +Inf sentinel, and one asserting each refusal and each failed attempt lands on its own counter. Verified the sentinel subtest fails with the Set removed.
|
|
||
| age := p.cfg.NowFunc().Sub(at) | ||
| if age > p.cfg.MaxEpochStaleness { | ||
| metrics.Errors.WithLabelValues(metrics.ErrorTypePingerEpochUnavailable).Inc() |
There was a problem hiding this comment.
"No epoch ever fetched" (boot ordering, bad ledger URL, line 214) and "gave up after MaxEpochStaleness" (outage, here) share one errors_total value, which is what the fleet alert fires on. Split into two error types while the type is being introduced.
There was a problem hiding this comment.
Split, into three rather than two. pinger_epoch_never_fetched for boot ordering or a bad ledger URL, pinger_epoch_too_stale for giving up after the bound, and pinger_epoch_ended for the new projected-rollover refusal. Covered by each_refusal_and_each_failed_attempt_is_counted_separately, which asserts each one moves on its own trigger and not on the others.
| // Debug, not Warn: markEpochStale carries the operator-facing signal, collapsed into | ||
| // the fresh->stale transition. A per-attempt warning here means thousands of lines | ||
| // across a multi-hour outage. | ||
| p.log.Debug("Failed to get current epoch, retrying", "attempt", attempt) |
There was a problem hiding this comment.
The demotion itself is right, but the fetch-failure signal now has no coverage in one direction and no debounce in the other. When retries eventually succeed (the partial-outage mode) markEpochStale never runs, so there is no signal above Debug and no metric at all. When the RPC flaps at the 10s refresh, each flap emits a Warn (330) plus an Info (302) — against this PR's own single-digit-log-lines goal. Add a per-failed-attempt counter here, and require N consecutive failures before the Warn.
There was a problem hiding this comment.
Both fixed.
Coverage in the eventually-succeeds direction: pinger_epoch_fetch_failed now increments per failed attempt inside getEpochInfo, so a partially degraded endpoint whose retries ride it out still leaves a signal even though markEpochStale never runs. Kept at Debug for the log line itself, for the reason in the original comment.
Debounce in the other: the fresh→stale Warn now waits for epochStaleWarnAfter (3) consecutive failed fetches, and the recovery Info is gated on whether that Warn actually fired — so a flap produces neither, rather than one of each. storeEpoch resets the counter.
Test: does_not_report_a_fallback_the_retries_recovered_from runs two flaps and asserts nothing above Debug, then a sustained outage and asserts exactly one Warn on the third consecutive failure and one Info on recovery. Verified it fails with the debounce removed.
| cfg.MaxEpochStaleness = DefaultMaxEpochStaleness | ||
| } | ||
| if cfg.NowFunc == nil { | ||
| cfg.NowFunc = func() time.Time { return time.Now().UTC() } |
There was a problem hiding this comment.
time.Now().UTC() strips the monotonic reading, so NowFunc().Sub(at) is pure wall-clock arithmetic. A device booting with a bad RTC and then NTP-stepping forward trips the staleness bound instantly and stops probing until the next successful fetch. Keep a monotonic timestamp alongside epochAt and measure age from it.
There was a problem hiding this comment.
Fixed. NowFunc defaults to time.Now rather than time.Now().UTC(), so the reading carries monotonic and NowFunc().Sub(at) is monotonic arithmetic; the .UTC() moved to the log call sites where it belongs. A bad RTC stepping forward over NTP no longer looks like hours of staleness.
The collector deliberately no longer passes Config.NowFunc into PingerConfig — that one is the UTC wall clock the sender cache uses for its bookkeeping, and it strips exactly the reading this needs. Comment at the call site says so, so it does not get helpfully wired back.
Injected test clocks are wall-only, which Sub handles by falling back to wall-clock arithmetic; that is what the staleness and rollover subtests rely on.
|
|
||
| // EpochRefreshInterval is how often the cached epoch is refreshed in the background. | ||
| // Defaults to Interval, which keeps the epoch RPC rate the same as when the fetch was inline. | ||
| EpochRefreshInterval time.Duration |
There was a problem hiding this comment.
EpochRefreshInterval is a new config field with no CLI flag and no non-test caller, and its defaultEpochRefreshInterval fallback (lines 24-25, 75-77) is unreachable: Config.Validate rejects ProbeInterval <= 0 (config.go:96) and Run would panic in time.NewTicker(0) first. Either wire a flag next to -max-epoch-staleness — the epoch resolves once per ~44h, so refreshing every 10s is ~8.6k RPC calls/day/device that an operator now cannot turn down — or drop the field and the constant and read Interval directly.
There was a problem hiding this comment.
Wired a flag: -epoch-refresh-interval, next to -max-epoch-staleness. It defaults to 0, which follows -probe-interval, so the epoch RPC rate is unchanged from before this PR and stays coupled if an operator changes the probe interval — and the ~8.6k calls/day/device can now be turned down without touching the probe cadence.
The unreachable defaultEpochRefreshInterval fallback stays, but it is no longer dead: PingerConfig is constructed directly by tests with no Interval set, and NewPinger would otherwise hand time.NewTicker a zero. Config.Validate still rejects ProbeInterval <= 0 for the collector path.
| if c.MaxConsecutiveSenderLosses <= 0 { | ||
| c.MaxConsecutiveSenderLosses = 30 | ||
| } | ||
| if c.MaxEpochStaleness <= 0 { |
There was a problem hiding this comment.
-max-epoch-staleness=0 silently becomes 12h, the opposite of the natural reading of the flag's "before giving up". main.go:117 already fails fast on probe-interval >= submission-interval; reject <= 0 there too rather than defaulting silently past an operator's intent.
There was a problem hiding this comment.
Fixed in main.go, next to the probe-interval >= submission-interval check:
if *maxEpochStaleness <= 0 {
fmt.Println("max-epoch-staleness must be greater than 0")
os.Exit(1)
}Plus the same for a negative -epoch-refresh-interval, since 0 there means "follow -probe-interval" and is deliberate.
The zero-value default in Config.Validate stays. It is a library default for embedded callers who never see a flag, and the flag path can no longer reach it.
Bound the cached epoch properly. The 12h staleness default outlived the buffer under it: 4096 samples per partition at a 10s probe interval is 11.4h, and the submitter discards the whole backlog when it finds the partition over capacity, backdating every sample written afterwards. Default to 10h and clamp to what the buffer holds at the configured probe interval. A flat duration is also the wrong bound for the rollover it was meant to guard, so project when the cached epoch ends from the slot position GetEpochInfo already carries, measuring the slot rate from AbsoluteSlot deltas rather than trusting the 400ms target — that constant overshoots a ~44h ledger epoch by ~9%, up to ~4h right after a boundary. Make the refresh loop the sole writer of the epoch cache. The inline fetch in epochForTick made the probe path a second writer, so a tick could log an ERROR and count an error while a fresh epoch sat in the cache, report a recovery before the failure it recovered from, and leave the staleness gauge large right after a success. Every log line and gauge write now happens inside the critical section that flips its flag. Fix the signals. Split the one epoch error type into never-fetched, too-stale and rolled-over, count each failed fetch attempt so a partial outage whose retries succeed still leaves a trace, hold the fresh->stale warning until three consecutive failures so a flapping endpoint stays quiet, and emit +Inf on the staleness gauge when no epoch was ever fetched — that state used to read exactly like a healthy agent. Measure the epoch's age monotonically, so a device with a bad RTC that steps forward over NTP does not trip the staleness bound instantly. Wire -epoch-refresh-interval, and reject -max-epoch-staleness=0 rather than silently substituting the default for it.
|
Thanks — this was a good review. Every inline point is answered in its thread; the three from the review body that had no inline anchor are below. Store how long the epoch actually lasts, and don't store samples past it. Done, and it turned out to be the more important of the two bounds. I went with the measured slot rate rather than the ~15% round-down, since you offered both and the measurement is free at the refresh cadence. The baseline is the first observation rather than the previous one — at a 10s cadence a single delta is a few dozen slots, where RPC latency and commitment jitter are a large fraction of the reading, so consecutive deltas are useless. Once the baseline spans 5 minutes the measured duration is used, trimmed 5% so the projection lands before the real boundary if the ledger speeds up during an outage (while the fetch is failing we can't notice). Until then it falls back to 340ms — the 400ms target less 15%, per your number. Clamped to [200ms, 1s], and the reading rebases if the slot counter or the clock moves backwards. Test: Didn't reach for Stale-epoch samples are unreachable through the time-range read path. Confirmed, and stated plainly in the changelog: samples taken against a cached epoch land in that epoch's account, so a query scoped to a later epoch won't return them. No reader change, though. Of your two suggested fixes I took the second — bounding by the epoch's remaining time — and that removes the cause rather than compensating for it downstream: with the rollover bound in place the probe loop stops at the projected boundary, so cross-boundary writes need the projection to be wrong, not merely an outage to be long. Widening the reader's scan by one on each side would still be defensible as belt-and-braces (it would also pick up the ~10s of samples per boundary that normal operation produces, pre-existing), but it costs two extra account fetches on every time-range query and belongs in a PR about the read path rather than this one. Happy to file it if you'd rather have it. Submitter retries restart at batch 0. Agreed on all of it, including that this PR is what makes it routine. Filed as #4148 rather than folded in — it's a pre-existing submitter bug and the fix (batch index across attempts, or per-batch retry) wants its own tests. Not unowned. While there: your inline note about the over-capacity path discarding all of Two things worth flagging that weren't asked for:
|
Summary of Changes
Pinger.Tickfetched the current epoch first and returned early on failure, so one unreachable ledger RPC endpoint stopped all TWAMP probing. Probing is pure UDP and needs no ledger access — the epoch only builds the sample buffer'sPartitionKey. During the 2026-07-29 outage (#4130) this cost up to 19 hours of latency samples per device across 23 mainnet-beta devices, for probes that would have succeeded the whole time.time.Tickerbuffers only one tick, so an inline fetch swallowed roughly a dozen probe opportunities per failure. The refresh cadence follows-probe-intervaland can be set independently with-epoch-refresh-interval.-max-epoch-staleness, or when the cached epoch's projected end has passed. Both bounds are load-bearing and neither is arbitrary:-probe-intervalnarrows it automatically.GetEpochInfoalready returnsSlotIndex/SlotsInEpoch/AbsoluteSlot, so the epoch's remaining time is free. Probing past it would file samples under the previous epoch with timestamps in the next epoch's window, wheregetCircuitLatenciesForTimeRangenever looks — it resolves the range to epoch numbers and iterates only those accounts. The slot rate is measured fromAbsoluteSlotdeltas over a long baseline rather than assumed: the 400ms target overshoots a ~44h ledger epoch by ~9%, up to ~4h right after a boundary.pinger_epoch_never_fetched,pinger_epoch_too_stale,pinger_epoch_ended) — a bad ledger URL and a multi-hour outage want different alerts. Repeated fetch failures collapse into fresh→stale and stale→fresh transitions, and the transition itself waits for three consecutive failures so a flapping endpoint stays quiet.pinger_epoch_fetch_failedcounts every failed attempt, so a partial outage whose retries succeed still leaves a signal. The per-attempt retry warning in the fetch drops to Debug — at a 10s cadence it emitted thousands of lines across the outage, which is what [TRACKER] Telemetry outage 2026-07-29 — ledger RPC wedge and agent resilience #4130's "single-digit log lines per component" exit criterion is about.doublezero_device_telemetry_agent_epoch_cache_stale_age_secondsreports the cached epoch's age while the fetch is failing, 0 when fresh, and+Infwhen no epoch has ever been fetched — the state where nothing is probed at all, which would otherwise read as healthy.Follow-ups filed rather than folded in, both pre-existing submitter bugs this change makes reachable at scale: #4148 (retries restart at batch 0, duplicating committed samples) and #4149 (the over-capacity path discards the whole backlog instead of the oldest slice).
Testing Verification
MaxEpochStalenesswith an injected clock, and stops at the projected rollover withMaxEpochStalenessset far larger — so only the rollover bound can be what stopped it.-racetest drivesTickconcurrently withRunwhile the fetch flaps and asserts no ERROR is emitted and no refusal is counted while an epoch is cached — the regression for the inline fetch making the probe path a second writer of the cache.+Infwhen nothing was ever fetched. Each refusal type and each failed attempt lands on its own counter.WriteDeviceLatencySamplesthat refuses every write, then asserts every sample survives the flush once writes recover — the regression for the bound outliving the buffer. It fails on its own premise if the bound ever drifts past what the buffer holds.Run-driven tests.internal/netns/TestRunInNamespace_EmptyNameErrorsfails identically on a cleanmain(needs privileges forsetns); unrelated to this change.